盒子
盒子
文章目录
  1. 0x00 TL;DR
  2. 0x01 Shadow Stack Overview
    1. Shadow Stack PTE
    2. Shadow Stack Management Instructions
  3. 0x02 Shadow Stack Implementation
    1. Signal
    2. Fork
    3. Ucontext
  4. 0x03 CET Bypass
    1. Overwrite Function
    2. Migrate Shadow Stack by RSTORSSP
  5. 0x04 Summary
  6. 0x05 Reference

Intel CET缓解措施深度研究

0x00 TL;DR

上一篇文章中已经简单介绍过了CET的基本原理和实际应用的一些技术,站在防守方的视角下,CET确实是一个能比较有效防御ROP攻击技术的措施。那么在攻击者的视角来看,研究清楚CET的技术细节,进而判断CET是否是一个完美的防御方案,还是存在一定的局限性,则是攻击方的重中之重。

本文由浅入深地讲述CET的实现细节,最后提出几个理论可行的绕过方案,供研究者参考。

0x01 Shadow Stack Overview

上一篇文章已经大概对CET做了个基本概念介绍,所以就不重复,直接说重点。

Shadow Stack PTE

Shadow Stack本质上是块内存页,属于新增的页类型,因此需要增加一个新的页属性来标识Shadow Stack。PTE中的一些位有被CPU定义的,也有保留给操作系统使用的,例如第0位的Present就由CPU标识页是否分配。Linux操作系统没有将所有保留位都使用掉(用于别的用途),但是其他操作系统则没有剩余可用的保留位了,因此从Linux中取一个未使用的位,不太可取。

这里Linux采用了复用很少使用的页状态(写时复制的状态):write=0, dirty=1。当Linux需要创建写时复制write=0, dirty=1的页时,用软件定义的_PAGE_COW代替_PAGE_DIRTY,创建shadow stack时,则使用write=0, dirty=1。这就将两者区分开来了:

1
2
3
4
5
6
7
8
9
10
11
#define _PAGE_BIT_SOFTW5	58	/* available for programmer */

#ifdef CONFIG_X86_SHADOW_STACK
#define _PAGE_BIT_COW _PAGE_BIT_SOFTW5 /* copy-on-write */
#endif

#ifdef CONFIG_X86_SHADOW_STACK
#define _PAGE_COW (_AT(pteval_t, 1) << _PAGE_BIT_COW)
#endif

#define _PAGE_DIRTY_BITS (_PAGE_DIRTY | _PAGE_COW)

Shadow Stack Management Instructions

为了保证shadow stack的独特性,CET专门设计了独有的汇编指令。普通的指令(MOV, XSAVE…)将不被允许操作shadow stack。

1
2
3
4
5
RDSSP #读取shadow stack指针
INCSSP #shadow stack指针加1
SAVEPREVSSP #保存先前shadow stack指针
RSTORSSP #恢复保存的shadow stack指针
...

这里重点说SAVEPREVSSP、RSTORSSP。Linux环境下,会存在栈切换的情况(系统调用、信号处理…),为了保证shadow stack的正常运作,数据栈切换后shadow stack也需要相应切换,因此就会用到这两个指令。

下图为执行RSTORSSP指令前后的shadow stack状态变化。执行的操作为先将SSP指针指向new shadow stack的‘restore token’,即0x4000。然后用current(old) shadow stack的地址做‘new restore token’替换掉‘restore token’,用于后续的SAVEPREVSSP指令使用。

rstorssp

下图为执行SAVEPREVSSP指令前后的变化。执行的操作为将前面设置的‘new restore token’压入previous shadow stack中,并将标志位置0。然后将SSP指针加1。

saveprevssp

至此,就完成了shadow stack切换的整个过程。

0x02 Shadow Stack Implementation

这里不提及Shadow Stack的普遍情况(见上一篇文章),只研究Shadow Stack在一些特殊场景下的实现,在这些场景中光申请Shadow Stack页后做push/pop操作是不够的,往往需要更复杂的实现。

Signal

一般用户需要对某个信号做自定义的特殊处理时,就会用到信号。

对应的函数为signal()、sigaction():

1
2
3
4
5
6
7
#include <signal.h>

typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);

int sigaction(int signum, const struct sigaction *restrict act,
struct sigaction *restrict oldact);

当捕获信号到执行信号处理函数再到恢复正常执行的整个过程中,会经历进程挂起、Ring0和Ring3间的切换、上下文切换等操作,这都需要shadow stack作出相应的变化,否则就会出现不可知的异常。下图是信号处理期间进程的变化。

signal

以signal函数举例,在glibc中它的具体实现为下面所示,最终会调用rt_sigaction去注册信号。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int
__libc_sigaction (int sig, const struct sigaction *act, struct sigaction *oact)
{
int result;
struct kernel_sigaction kact, koact;
if (act)
{
kact.k_sa_handler = act->sa_handler;
//...
SET_SA_RESTORER (&kact, act); //(kact)->sa_restorer = __NR_rt_sigreturn
}

result = INLINE_SYSCALL_CALL (rt_sigaction, sig, //rt_sigaction系统调用,作用为注册信号
act ? &kact : NULL,
oact ? &koact : NULL, STUB (act, _NSIG / 8));
//...
return result;
}

再看CET的实现,它在__setup_rt_frame函数中添加了shadow stack相关的操作函数,__setup_rt_frame函数会在信号处理过程中被调用,即上面信号处理期间进程变化的图中②的期间:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
static int __setup_rt_frame(int sig, struct ksignal *ksig,
sigset_t *set, struct pt_regs *regs)
{
//...
if (setup_signal_shadow_stack(0, ksig->ka.sa.sa_restorer)) //第二个参数即为__NR_rt_sigreturn
return -EFAULT;
//...
}

int setup_signal_shadow_stack(int ia32, void __user *restorer)
{
unsigned long new_ssp;
int err;

err = shstk_setup_rstor_token(ia32, (unsigned long)restorer,
&new_ssp); //创建restor token,并push用户态的函数返回地址

err = wrmsrl_safe(MSR_IA32_PL3_SSP, new_ssp); //更新ssp指针

return err;
}

上面新增的setup_signal_shadow_stack函数,参数restorer即为前面__libc_sigaction函数中提到的__NR_rt_sigreturn系统调用,且该参数后续会被push到shadow stack中去作为新的函数返回地址。

相应地,再看__NR_rt_sigreturn系统调用的实现,该调用会在上面信号处理期间进程变化的图中④执行,CET也在该处做了相应的改动:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
SYSCALL_DEFINE0(rt_sigreturn)
{
struct pt_regs *regs = current_pt_regs();
struct rt_sigframe __user *frame;
sigset_t set;
unsigned long uc_flags;
//...

if (restore_signal_shadow_stack()) //新增shadow stack相关代码
goto badframe;

badframe:
signal_fault(regs, frame, "rt_sigreturn");
return 0;
}

int restore_signal_shadow_stack(void)
{
struct thread_shstk *shstk = &current->thread.shstk;
int ia32 = in_ia32_syscall();
unsigned long new_ssp;
int err;

err = shstk_check_rstor_token(ia32, &new_ssp); //校验前面创建的restore token是否符合条件,并赋值为new ssp

err = wrmsrl_safe(MSR_IA32_PL3_SSP, new_ssp); //更新ssp指针
return err;
}

从上面rt_sigreturn新增代码结合__setup_rt_frame新增代码可知,两者是相互配合的:一个负责创建restore token并在shadow stack设置返回地址,另一个则负责校验restore token并设置新的ssp,以此来兼容在信号处理过程中数据栈切换、上下文切换的场景。

至于为什么要在创建restore token后设置shadow stack返回地址,是因为在信号处理过程中执行完sa_handler用户自定义函数后,紧接着就会执行sa_restorer所设置的函数,因此在CET场景下需要在shadow stack设置相应的返回地址。

Fork

调用fork后,存在两种情况:

  1. 子进程和父进程分别有自己的一块内存,不共享
  2. 子进程和父进程共享同一块内存,为vfork

因此,在shadow stack场景下,需要对fork系统调用做特殊处理。

fork调用链如下:

1
2
3
4
SYSCALL_DEFINE0(fork)/SYSCALL_DEFINE0(vfork)
-> pid_t kernel_clone()
-> struct task_struct *copy_process()
-> int copy_thread()

CET在copy_thread函数中添加了相关代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
int copy_thread(unsigned long clone_flags, unsigned long sp,
unsigned long stack_size, struct task_struct *p,
unsigned long tls)
{
int ret = 0;
//...
/* Allocate a new shadow stack for pthread */
if (!ret)
ret = shstk_alloc_thread_stack(p, clone_flags, stack_size); //新增shadow stack代码

return ret;
}

int shstk_alloc_thread_stack(struct task_struct *tsk, unsigned long clone_flags,
unsigned long stack_size)
{
struct thread_shstk *shstk = &tsk->thread.shstk;
unsigned long addr;

/*
* For CLONE_VM, except vfork, the child needs a separate shadow
* stack.
*/
if ((clone_flags & (CLONE_VFORK | CLONE_VM)) != CLONE_VM) //针对vfork场景做特殊处理
return 0;

stack_size = round_up(stack_size, PAGE_SIZE);
addr = alloc_shstk(stack_size); //申请一块新的shadow stack

shstk->base = addr;
shstk->size = stack_size;
return 0;
}

从上面新增的代码可知,CET针对fork系统调用过程增加了创建新的shadow stack的部分,以兼容fork后父子进程不共享内存的情况。同时也对vfork后父子进程共享内存的情况做了处理,使得不创建新的shadow stack以兼容相应场景。

Ucontext

ucontext涉及到协程相关的技术,该技术和系统调用在R3、R0间的切换比较类似。但是该技术作用于用户态,目的是给用户态程序提供更快的切换效果,以及使得用户态的代码能够更加灵活。在用户态层面实现上下文切换。

常用的函数为getcontext/setcontext:

1
2
3
4
#include <ucontext.h>

int getcontext(ucontext_t *ucp);
int setcontext(const ucontext_t *ucp);

setjmp/longjmp的技术原理和实现和ucontext类似,就不提及了。getcontext/setcontext具体实现都在glibc中。

ucontext协程技术涉及到上下文切换的场景,也会存在数据栈切换的情况,因此,shadow stack也需要做出相应的动作。

先看shadow stack在getcontext中的改动,先用__NR_arch_prctl系统调用获取当前shadow stack的基地址,其次将其保存在SSP_BASE_OFFSET寄存器中,随后保存shadow stack基地址、ssp值在ucontext结构体中,供后续setcontext使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#if SHSTK_ENABLED
/* 检查shadow stack是否enabled. */
testl $X86_FEATURE_1_SHSTK, %fs:FEATURE_1_OFFSET
jz L(no_shstk)
movq %rdi, %rdx
xorl %eax, %eax
cmpq %fs:SSP_BASE_OFFSET, %rax
jnz L(shadow_stack_bound_recorded)
/* 获取当前shadow stack的基地址和栈大小 */
sub $24, %RSP_LP
mov %RSP_LP, %RSI_LP
movl $ARCH_CET_STATUS, %edi
movl $__NR_arch_prctl, %eax
syscall
testq %rax, %rax
jz L(continue_no_err)
hlt
L(continue_no_err):
/* 赋值寄存器SSP_BASE_OFFSET,保存着当前shadow stack基地址 */
movq 8(%rsp), %rax
movq %rax, %fs:SSP_BASE_OFFSET
add $24, %RSP_LP
movq %rdx, %rdi
L(shadow_stack_bound_recorded):
rdsspq %rax
addq $8, %rax
movq %rax, oSSP(%rdx) /* 保存ssp+8在ucontext结构体中 */
movq %fs:SSP_BASE_OFFSET, %rax
movq %rax, (oSSP + 8)(%rdi) /* 保存shadow stack基地址 */
#endif

再来看setcontext中的改动,校验getcontext保存的ucontext中的shadow stack基地址和ssp,再恢复,达到切换回上文状态的目的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#if SHSTK_ENABLED
/* 检查shadow stack是否enabled. */
testl $X86_FEATURE_1_SHSTK, %fs:FEATURE_1_OFFSET
jz L(no_shstk)
movq oSSP(%rdx), %rsi /* 获取ssp+8 */
movq %rsi, %rdi
movq (oSSP + 8)(%rdx), %rcx /* 获取shadow stack基地址 */
cmpq %fs:SSP_BASE_OFFSET, %rcx /* 对比是否和当前shadow stack基地址相同 */
je L(unwind_shadow_stack)
L(unwind_shadow_stack): /* ssp递增到前面getcontext的位置,相当于恢复到getcontext时候的ssp状态 */
rdsspq %rcx
subq %rdi, %rcx
je L(skip_unwind_shadow_stack)
negq %rcx
shrq $3, %rcx
movl $255, %esi
L(loop):
cmpq %rsi, %rcx
cmovb %rcx, %rsi
incsspq %rsi
subq %rsi, %rcx
ja L(loop)
L(skip_unwind_shadow_stack):
movq oRSI(%rdx), %rsi
movq oRDI(%rdx), %rdi
movq oRCX(%rdx), %rcx
movq oR8(%rdx), %r8
movq oR9(%rdx), %r9
/* 获取getcontext保存的返回地址,RIP */
movq oRIP(%rdx), %r10
movq oRDX(%rdx), %rdx
/* 检查返回地址是否有效(即shadow stack中是否存在) */
rdsspq %rax
cmpq (%rax), %r10
movl $0, %eax
/* 返回地址有效则ret过去 */
pushq %r10
ret
#endif

上面getcontext/setcontext的场景,是在同一块shadow stack中实现切换,因为进程并没有创建新的数据栈。此外,makecontext会创建一个新的数据栈,开辟一个新的上下文,和上面的场景又有些许不同,makecontext和setcontext也都做了相应的改动,由于篇幅原因不过多叙述,读者自行阅读源码即可,技术原理都是一样的。

0x03 CET Bypass

CET在多场景下的实现还是相对复杂的,需要软件层面做相应的配合,因此在复杂的设计实现层面,是否有可能存在绕过CET的可能性呢?本小节提出几个理论可行的方案供研究者参考。

Overwrite Function

该方法比较简单粗暴,篡改结构体中的函数指针来控制执行流。假设现有如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <stdio.h>

typedef struct str{
int num;
void (*test)(void);
}str;

void test_func(){
printf("hello\n");
}

void over_write(){
printf("over write success\n");
}

int main(){
str str1;
str1.test = test_func;
str1.test(); // (1)
str1.test = over_write; // (2)
str1.test();

return 0;
}

调用结构体函数(1)处的汇编代码如下:

1
2
mov    rax, qword ptr [rbp - 8]
call rax

此时有间接call,IBT机制会起作用,call rax后一条指令必须为ENDBR64。

如果此时拥有任意读写的能力,就可以篡改结构体str1的test函数指针为over_write(2)即可改变执行流。且此时over_write函数的入口点也是ENDBR64,即可绕过IBT的检查:

1
2
3
4
5
6
7
8
► 0x40050b <over_write>       endbr64
0x40050f <over_write+4> push rbp
0x400510 <over_write+5> mov rbp, rsp
0x400513 <over_write+8> mov edi, 0x4005da
0x400518 <over_write+13> call puts@plt <puts@plt>
0x40051d <over_write+18> nop
0x40051e <over_write+19> pop rbp
0x40051f <over_write+20> ret

IBT机制会给绝大部分函数体的入口点添加ENDBR指令,因此这种方法还是可行的,实际测试:

over_write

扩展一下,还可以利用JOP去做。例如使用以下序列,也可以绕过CET:

1
2
3
4
5
6
7
8
9
10
[1]:
endbr64
mov rax, rdi
jmp [rsp+8]

[2]:
endbr64
jmp rax

#...

但是这种JOP序列实际上是比较稀少的,难找到。

Migrate Shadow Stack by RSTORSSP

这种方案利用了CET新增的指令来做文章。前面已经介绍过了RSTORSSP,用于shadow stack的切换,那么如果切换到的是攻击者伪造的shadow stack呢?

整个过程比较简单,步骤如下:

  1. 构造一块可控内存
  2. 在可控内存中事先构造好返回地址,后续作为shadow stack使用
  3. 将内存转变为shadow stack
  4. 构造ROP
  5. ROP利用rstorssp将原shadow stack迁移到伪造的shadow stack中
  6. ROP执行system

CET针对mmap和mprotect都做了相应的改动,在mmap中主要增加了一个VMA_FLAG为VM_SHADOW_STACK的属性,在mprotect中除了PROT_READ/PROT_WRITE外增加了PROT_SHADOW_STACK(有一点是PROT_WRITE和PROT_SHADOW_STACK不能同时使用,即只读),这两者是互相对应的关系。

简单编写了这种方案的demo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <asm/prctl.h> /* Definition of ARCH_* constants */
#include <sys/syscall.h> /* Definition of SYS_* constants */
#include <unistd.h>
#include <sys/mman.h>
#include <x86intrin.h>

#define ARCH_X86_CET_STATUS 0x3001
#define PROT_SHSTK 0x10
#define SHSTK_SIZE 0x1000

void test_func(){
printf("new shstk area works ok!\n");
exit(0);
return;
}

int main(){
uint64_t buf[3] = {0};

// get origin shstk area
syscall(SYS_arch_prctl, ARCH_X86_CET_STATUS, buf);
printf("origin shstk area: 0x%llx, size: 0x%x\n", buf[1], buf[2]);

// fake shstk area
void *new_shstk = mmap(0, SHSTK_SIZE, PROT_READ | PROT_WRITE, (MAP_PRIVATE | MAP_ANONYMOUS), 0, 0);
printf("new shstk area: 0x%llp, size: 0x%x\n", new_shstk, SHSTK_SIZE);
// fake ret addr
*((uint64_t *)((uint64_t)new_shstk + SHSTK_SIZE - 0x40)) = ((uint64_t)new_shstk + SHSTK_SIZE - 0x38) | 1;
*((uint64_t *)((uint64_t)new_shstk + SHSTK_SIZE - 0x38)) = 0x41414141;
mprotect(new_shstk, SHSTK_SIZE, PROT_READ | PROT_SHSTK);

// change ssp
uint64_t rstor_val = (uint64_t)new_shstk + SHSTK_SIZE - 0x40;
uint64_t old_ssp = _get_ssp();
printf("origin ssp: 0x%llx\n", old_ssp);
asm volatile("rstorssp (%0)\n":: "r" (rstor_val));
uint64_t new_ssp = _get_ssp();
printf("new ssp: 0x%llx\n", new_ssp);

// test new shstk whether can be used
_inc_ssp(1);
buf[9] = 0x41414141;
//test_func();

//_inc_ssp(1);

return 0;
}

调试效果如下,可见当前已经将shadow stack切换到事先伪造的内存页中,且返回地址也篡改得和数据栈返回地址相同,为0x41414141:

切换

最终,RIP也能成功执行到控制的执行流:

rip

不过这种方法在实际场景中构造的要求比较高,局限性比较大。

当然了,还有更粗暴的方法,CET新增指令还有一个WRSS的指令,该指令可以直接在shadow stack中写数据。但是该指令需要在CPU上做使能操作,目前笔者阅读的源码暂时还没有使能,就不赘述了。

0x04 Summary

CET与以往软件实现的CFI不同,它从硬件侧寻找解决方案,在底层就将ROP掐断,对于软件CFI来说从性能、缓解效果角度来说都有着极大的提升。有得必有失,底层的变动必然会撬动上层随之变化,想要将这一缓解措施真正实施落地,还有着很长的一段路要走。笔者略浅地研究了一番CET当前的实施进展,提出了部分攻防方向上的想法,供后续研究者参考。我相信在不远的将来,CET的落地会给攻防带来很大的变化,到时候又将摩擦出怎样的火花呢?让我们一起期待吧。

0x05 Reference

  1. https://github.com/yyu168/linux_cet/commit/72367656271aba4d29a25b38232e680ab9231a26
  2. https://ty-chen.github.io/linux-kernel-signal/
  3. https://code.woboq.org/userspace/glibc/sysdeps/unix/sysv/linux/sigaction.c.html#__libc_sigaction
  4. https://man7.org/linux/man-pages/man2/signal.2.html
  5. https://code.woboq.org/userspace/glibc/sysdeps/unix/sysv/linux/x86_64/getcontext.S.html#137
  6. https://code.woboq.org/userspace/glibc/sysdeps/unix/sysv/linux/x86_64/setcontext.S.html#197
  7. https://man7.org/linux/man-pages/man3/getcontext.3.html
  8. https://lore.kernel.org/lkml/776fb081217145f4a488f7bca3e16eab@AcuMS.aculab.com/
  9. https://github.com/hjl-tools/linux/commit/280503098ea762b3100edb30d60489a030d4abca
  10. https://www.twblogs.net/a/5b7e1dd92b71776838556498
支持一下
扫一扫,支持v1nke
  • 微信扫一扫
  • 支付宝扫一扫